package com.lxy.file.service.impl;


import com.lxy.file.common.Result;
import com.lxy.file.constant.FileConstant;
import com.lxy.file.dto.FileChunkDTO;
import com.lxy.file.dto.FileChunkResultDTO;
import com.lxy.file.dto.MergeDTO;
import com.lxy.file.entity.BaseEntity;
import com.lxy.file.enums.ExceptionEnum;
import com.lxy.file.exception.BizException;
import com.lxy.file.service.IUploadService;
import com.lxy.file.util.RedisUtil;
import com.lxy.file.util.TypeConvertUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.tomcat.util.http.fileupload.IOUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;

import java.io.*;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.FutureTask;

/**
 * 分块上传
 *
 * @author LiuXiaoYu
 * @date 2021/9/3- 17:12
 */
@Service
@Slf4j
public class UploadServiceImpl implements IUploadService {


    @Value("${file.outPath}")
    private String outFilePath;

    private final RedisUtil redisUtil;
    private final ThreadPoolTaskExecutor asyncUploadTrunk;

    @Autowired
    public UploadServiceImpl(RedisUtil redisUtil, ThreadPoolTaskExecutor asyncUploadTrunk) {
        this.redisUtil = redisUtil;
        this.asyncUploadTrunk = asyncUploadTrunk;
    }

    /**
     * 校验md5,检查文件是否存在，如果存在则跳过该文件的上传，如果不存在，返回需要上传的分片集合
     *
     * @param chunkDTO 分块对象
     * @return 自定义结果
     */
    @Override
    public Result<?> checkChunkExist(FileChunkDTO chunkDTO) {

        //1.检查文件是否已上传过
        //1.1)检查在磁盘中是否存在
        //得到上传文件的分块合成之后的路径
        String fileFolderPath = getFileFolderPath(chunkDTO.getMd5Value());
        //得到上传文件的分块文件路径
        String filePath = getFilePath(chunkDTO.getMd5Value(), chunkDTO.getFilename());
        //在分块文件路径下创建文件
        File file = new File(filePath);
        //判断该上传文件路径是否存在
        boolean exists = file.exists();
        //1.2)检查Redis中是否存在,并且所有分片已经上传完成。
        //从redis中查询该文件的所有已经成功上传的分块集合

        Set<Integer> uploaded = new TypeConvertUtil<Integer>().typeConvertToArray(redisUtil.hget(chunkDTO.getMd5Value(), FileConstant.UPLOADED));
        if (Objects.nonNull(uploaded) && uploaded.size() == chunkDTO.getTotalChunks() && exists) {
            //说明文件的全部分块已经上传成功了
            return Result.error(ExceptionEnum.ALREADY_EXIST, "该文件已存在，无需再次上传", fileFolderPath);
        }

        File fileFolder = new File(fileFolderPath);
        if (!fileFolder.exists()) {
            //1.3说明该文件的从未上传过或者该文件并没有合并 则创建文件夹
            boolean mkdirs = fileFolder.mkdirs();
            log.info("准备工作,创建文件夹,fileFolderPath:{},mkdirs:{}", fileFolderPath, mkdirs);
        }
        //返回成功上传的分块序号集合 uploaded为null从未上传成功
        // 不为null说明有部分上传成功/或者全部分块上传成功 只是没有合并
        return Result.error(ExceptionEnum.NO_EXIST, new FileChunkResultDTO(uploaded));
    }


    /**
     * 开始上传分片
     *
     * @param chunkDTO 分块对象
     * @return CompletableFuture对象
     */
    @Override
    public CompletableFuture<Result<String>> uploadChunk(FileChunkDTO chunkDTO) {
        return CompletableFuture.supplyAsync(() -> {
            //根据md5生成分块的目录
            String chunkFileFolderPath = getChunkFileFolderPath(chunkDTO.getMd5Value());
            //创建文件对象
            File chunkFileFolder = new File(chunkFileFolderPath);
            //若文件对象不存在
            if (!chunkFileFolder.exists()) {
                //则创建文件夹
                boolean mkdirs = chunkFileFolder.mkdirs();
                log.info("创建分片文件夹:{}", mkdirs);
            }
            InputStream inputStream;
            FileOutputStream outputStream;
            //写入分片
            try {
                inputStream = chunkDTO.getFile().getInputStream();
                outputStream = new FileOutputStream(new File(chunkFileFolderPath + chunkDTO.getChunkNumber()));
                IOUtils.copy(inputStream, outputStream);
                inputStream.close();
                outputStream.close();
                //将该分片写入redis 得到该文件已经上传成功的分块长度
                long size = saveToRedis(chunkDTO);
                //如果全部分块都在Redis记录中 则进行合并分块
                //TODO 开启自动合并? True自动合并
                //True 自动合并
                if (size == chunkDTO.getTotalChunks() && chunkDTO.getAutoMerge()) {
                    File mergeFile = mergeChunks(chunkDTO.getMd5Value(), chunkDTO.getFilename());
                    return Result.success("自动合并分块文件成功,绝对路径:" + mergeFile.getAbsolutePath());
                }
                return Result.success(Thread.currentThread().getName() + ":当前分块数据已经存储完毕,md5:" + chunkDTO.getMd5Value() + ",当前分块编号:" + chunkDTO.getChunkNumber());
            } catch (Exception e) {
                return Result.error(ExceptionEnum.UPOLAOD_FILE_FAIL, e.getMessage(), "当前分块上传失败,分块编号:" + chunkDTO.getChunkNumber());
            }
        }, asyncUploadTrunk).whenComplete((res, e) -> log.info("res:" + res + ",感知到了异常:" + e)).exceptionally(e -> {
            log.info("completableFuture出现异常了", e);
            return Result.error(ExceptionEnum.UPOLAOD_FILE_FAIL, e.getMessage(), "当前分块上传失败,分块编号:" + chunkDTO.getChunkNumber());
        });
    }

    /**
     * 手动合并所有文件分块
     *
     * @param mergeDTO 合并对象
     * @return 自定义结果
     */
    @Override
    public Result<?> manualMerge(MergeDTO mergeDTO) {
        //TODO 检查所有文件分块是否都齐全 False->手动合并
        return checkAllTrunks(mergeDTO);
    }

    /**
     * 手动合并所有文件分块时检验文件分块是否齐全
     *
     * @param baseEntity 分块基类
     */
    private Result<?> checkAllTrunks(BaseEntity baseEntity) {
        Set<Integer> uploaded = new TypeConvertUtil<Integer>().typeConvertToArray(redisUtil.hget(baseEntity.getMd5Value(), FileConstant.UPLOADED));
        if (Objects.isNull(uploaded)) {
            //说明该数据过期了或者被删除了
            return Result.error(ExceptionEnum.UPOLAOD_FILE_FAIL, "文件上传失败,Redis中的分块数据过期了或者被删除了");
        }
        //该文件的分块数据还存在
        //验证分块数据是否齐全 当该文件的所有分块数据都在而且为手动合并模式
        if (baseEntity.getTotalChunks() == uploaded.size()) {
            File mergeFile = mergeChunks(baseEntity.getMd5Value(), baseEntity.getFilename());
            return Result.success("自动合并分块文件成功,绝对路径:" + mergeFile.getAbsolutePath());
        } else if (baseEntity.getAutoMerge()) {
            //说明设置的是自动合并,返回错误信息
            return Result.error(ExceptionEnum.UPOLAOD_FILE_FAIL, "文件上传失败，请启用手动合并模式");
        }
        /*说明Redis中只含有部分分块数据 返回错误信息给前端
         再重新上传文件,只不过前端代码处理时 只上传没有成功的分块而已*/
        return Result.error(ExceptionEnum.UPOLAOD_FILE_FAIL, "文件上传失败,文件的部分分块没有成功上传,请重新上传");
    }

    /**
     * 合并分块 只有当该文件所有分块上传后才会调用合并分块方法
     *
     * @param md5Value md5
     * @param filename 文件名称
     */
    private File mergeChunks(String md5Value, String filename) {
        //得到分块文件目录
        String chunkFileFolderPath = getChunkFileFolderPath(md5Value);
        //得到合并分块的目录
        String filePath = getFilePath(md5Value, filename);
        //创建分块文件目录的文件对象
        File chunkFileFolder = new File(chunkFileFolderPath);
        //创建合并分块的文件对象
        File mergeFile = new File(filePath);
        //列出当前文件的分块文件目录下的所有文件
        File[] chunks = chunkFileFolder.listFiles();
        //排序 断言分块文件属组不为null
        assert Objects.nonNull(chunks);
        //根据分块序号排个序
        Arrays.sort(chunks, (o1, o2) -> {
            Integer o1Value = Integer.valueOf(o1.getName().substring(o1.getName().lastIndexOf(File.separator) + FileConstant.ONE));
            Integer o2Value = Integer.valueOf(o2.getName().substring(o2.getName().lastIndexOf(File.separator) + FileConstant.ONE));
            return o1Value - o2Value;
        });

         /* TODO RandomAccessFile VS MappedByteBuffer VS FileChannel
             建议使用MappedByteBuffer内存映射比RandomAccessFile快
             学习之后再做再改,先用常见的RandomAccessFile*/
        try {
            //读写模式创建RandomAccessFile
            RandomAccessFile randomAccessFileWriter = new RandomAccessFile(mergeFile, FileConstant.READ_WRITE);
            byte[] bytes = new byte[FileConstant.ONE_KB];
            for (File chunk : chunks) {
                //读模式创建RandomAcessFile
                RandomAccessFile randomAccessFileReader = new RandomAccessFile(chunk, FileConstant.ONLY_READ);
                int len;
                //开始进行分块合并
                while ((len = randomAccessFileReader.read(bytes)) != FileConstant.NEGATIVE_ONE) {
                    randomAccessFileWriter.write(bytes, FileConstant.ZERO, len);
                }
                randomAccessFileReader.close();
            }
            randomAccessFileWriter.close();
        } catch (Exception e) {
            throw new BizException(ExceptionEnum.UPOLAOD_FILE_FAIL, "合并分块文件过程中出现异常");
        }
        return mergeFile;
    }

    /**
     * 分块对象信息写入Redis
     *
     * @param chunkDTO 分块对象
     */
    private synchronized long saveToRedis(FileChunkDTO chunkDTO) {
        //从Redis中查询该文件已经上传成功的分块集合
        Set<Integer> uploaded = new TypeConvertUtil<Integer>().typeConvertToArray(redisUtil.hget(chunkDTO.getMd5Value(), FileConstant.UPLOADED));
        if (Objects.isNull(uploaded) || uploaded.size() == FileConstant.ZERO) {
            //说明该文件从未上传成功
            uploaded = new HashSet<>(Collections.singletonList(chunkDTO.getChunkNumber()));
            Map<String, Object> map = new HashMap<>();
            map.put("uploaded", uploaded);
            map.put("totalChunks", chunkDTO.getTotalChunks());
            map.put("totalSize", chunkDTO.getTotalSize());
            map.put("path", getFilePath(chunkDTO.getMd5Value(), chunkDTO.getFilename()));
            redisUtil.hmset(chunkDTO.getMd5Value(), map);
        } else {
            //说明该文件有分块上传成功过
            uploaded.add(chunkDTO.getChunkNumber());
            //添加当前文件的分块序号到Redis
            redisUtil.hset(chunkDTO.getMd5Value(), FileConstant.UPLOADED, uploaded);
        }
        return uploaded.size();
    }

    /**
     * 得到文件的绝对路径
     *
     * @param md5Value md5字符串
     * @param filename 文件名称
     * @return 文件字符串路径
     */
    private String getFilePath(String md5Value, String filename) {
        return getFileFolderPath(md5Value) + filename;
    }


    /**
     * 得到分块文件所属的目录
     *
     * @param md5Value md5字符串
     * @return 分块文件路径
     */
    private String getChunkFileFolderPath(String md5Value) {
        return getFileFolderPath(md5Value) + FileConstant.TRUNKS + File.separator;
    }

    /**
     * 得到文件所属的目录
     *
     * @param md5Value md5字符串
     * @return 要上传的目录路径
     */
    private String getFileFolderPath(String md5Value) {
        return outFilePath + md5Value + File.separator;
    }
}
